/*
 * Copyright 2013 Sigurd Randoll <srandoll@digiway.de>.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package de.digiway.rapidbreeze.server.model.download;

import de.digiway.rapidbreeze.server.infrastructure.objectstorage.Column;
import de.digiway.rapidbreeze.server.infrastructure.objectstorage.Entity;
import de.digiway.rapidbreeze.server.infrastructure.objectstorage.Id;
import de.digiway.rapidbreeze.shared.rest.download.DownloadStatus;
import de.digiway.rapidbreeze.server.model.storage.StorageProvider;
import de.digiway.rapidbreeze.server.model.storage.StorageProviderDownloadClient;
import de.digiway.rapidbreeze.server.model.storage.UrlStatus;
import java.io.IOException;
import java.io.Serializable;
import java.net.URL;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Date;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.Validate;

/**
 * An entity class which represents a {@linkplain Download} from a specific
 * {@linkplain StorageProvider}. A {@linkplain Download} instance can be
 * started, paused and resumed. This class is not thread safe.
 *
 * @author Sigurd Randoll <srandoll@digiway.de>
 */
@Entity(table = "Download")
public class Download implements Serializable {

    public static final String PROP_CREATED = "created";
    private static final long serialVersionUID = 1L;
    @Id
    private String identifier;
    @Column
    private Path targetFile;
    @Column
    private Path tempFile;
    @Column
    private URL url;
    @Column
    private StorageProvider storageProvider;
    @Column
    private Boolean done;
    @Column
    private Date created;
    private transient Long throttleMaxBytesPerSecond = 0L;
    private transient ReadableByteChannel sourceChannel = null;
    private transient FileChannel targetChannel = null;
    private transient ThrottledInputStream throttledInputStream;
    private transient DownloadStatusHandler statusHandler = new DownloadStatusHandler(this);
    private transient UrlStatus cachedUrlStatus = null;
    private static transient final Logger LOG = Logger.getLogger(Download.class.getName());
    public static final long BLOCK_SIZE = 4096;
    private static final int DEFAULT_IDLE = 100;

    protected Download() {
        // Required by persisting framework
    }

    /**
     * Creates a new instance to handle a download from the given URL to the
     * given target file with the provided {@linkplain StorageProvider}.
     *
     * @param url
     * @param targetFile
     * @param storageProvider
     */
    Download(URL url, Path targetFile, StorageProvider storageProvider) {
        Validate.notNull(url, "Url is a mandatory parameter.");
        Validate.notNull(targetFile, "TargetFile is a mandatory parameter.");
        Validate.notNull(storageProvider, "StorageProvider is a mandatory parameter.");
        this.url = url;
        this.targetFile = targetFile;
        this.storageProvider = storageProvider;
        this.tempFile = Paths.get(targetFile.toString() + ".tmp");
        this.identifier = UUID.randomUUID().toString(); // Required here for hashCode and equals
        this.done = false;
        this.created = new Date();
    }

    /**
     * Returns the unique identifier of this download.
     *
     * @return non-null string
     */
    public String getIdentifier() {
        return identifier;
    }

    /**
     * Returns the {@linkplain Date} when this download was created.
     *
     * @return
     */
    public Date getCreated() {
        return created;
    }

    /**
     * Adds a listener to this {@linkplain Download} instance. The listener will
     * be informed about changes in the {@linkplain Download}. Pay attention:
     * The listener might be fired from another thread.
     *
     * @param listener
     */
    public void addListener(DownloadListener listener) {
        statusHandler.addListener(listener);
    }

    /**
     * Removes the given listener
     *
     * @param listener
     */
    public void removeListener(DownloadListener listener) {
        statusHandler.removeListener(listener);
    }

    /**
     * This call fetches the current {@linkplain UrlStatus} of this
     * {@linkplain Download}. For each running {@linkplain Download} the result
     * of this call is cached.
     *
     * @return
     */
    public UrlStatus getUrlStatus() {
        if (cachedUrlStatus == null) {
            cachedUrlStatus = getDownloadClient().getUrlStatus(url);
        }
        return cachedUrlStatus;
    }

    /**
     * Executes all pending actions for this {@linkplain Download} instance.
     *
     * @return the time in ms when the next call to handle shoule be executed.
     */
    int handle() {
        try {
            switch (statusHandler.getCurrentStatus()) {
                case RUNNING:
                    return handleRunning();
            }

        } catch (IOException | RuntimeException ex) {
            LOG.log(Level.SEVERE, "An exception occured during handling of the " + Download.class.getSimpleName() + ": " + this, ex);
            closeChannels();
            cachedUrlStatus = null;
            statusHandler.newException(ex);
        }
        return DEFAULT_IDLE;
    }

    private int handleRunning() throws IOException {
        long position = targetChannel.position();
        long transferred = targetChannel.transferFrom(sourceChannel, position, BLOCK_SIZE);
        targetChannel.position(position + transferred);

        if (targetChannel.size() == getUrlStatus().getFileSize()) {
            closeChannels();
            Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
            done = true;
            statusHandler.newStatus(DownloadStatus.FINISHED_SUCCESSFUL);
        }
        return throttledInputStream.nextTransfer(BLOCK_SIZE);
    }

    /**
     * Starts this {@linkplain Download}.
     *
     */
    void start() {
        switch (statusHandler.getCurrentStatus()) {
            case RUNNING:
                return;
            case PAUSE:
                statusHandler.newStatus(DownloadStatus.RUNNING);
                return;
        }

        try {
            long startAt = 0;
            if (Files.exists(tempFile)) {
                try {
                    startAt = Files.size(tempFile);
                } catch (IOException ex) {
                    // File might be removed in the meantime
                    startAt = 0;
                }
            }

            StorageProviderDownloadClient storageDownload = getDownloadClient();
            throttledInputStream = new ThrottledInputStream(storageDownload.start(url, startAt));
            throttledInputStream.setThrottle(throttleMaxBytesPerSecond);
            sourceChannel = Channels.newChannel(throttledInputStream);
            targetChannel = FileChannel.open(tempFile, StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE);
            targetChannel.position(startAt);
        } catch (IOException | RuntimeException ex) {
            LOG.log(Level.SEVERE, "An exception occured during data transfer setup for " + Download.class.getSimpleName() + ":" + this, ex);
            closeChannels();
            cachedUrlStatus = null;
            statusHandler.newException(ex);
            return;
        }

        done = false;
        statusHandler.newStatus(DownloadStatus.RUNNING);
    }

    private void closeChannels() {
        try {
            IOUtils.closeQuietly(sourceChannel);
            IOUtils.closeQuietly(targetChannel);
        } catch (RuntimeException re) {
            // Might occur if closeQuietly throws NP
        }
    }

    /**
     * Pauses the {@linkplain Download}.
     */
    void pause() {
        statusHandler.newStatus(DownloadStatus.PAUSE);
    }

    void waitState() {
        closeChannels();
        done = false;
        statusHandler.newStatus(DownloadStatus.WAITING);
    }

    private StorageProviderDownloadClient getDownloadClient() {
        StorageProviderDownloadClient storageDownload = storageProvider.createDownloadClient();
        return storageDownload;
    }

    /**
     * Removes the temporary file where the download is streamed to. The
     * download must be waiting or error.
     */
    public void removeTempFile() {
        if (!getDownloadStatus().equals(DownloadStatus.WAITING) && !getDownloadStatus().equals(DownloadStatus.ERROR)) {
            throw new IllegalStateException("Cannot remove temporary file. " + Download.class.getSimpleName() + " must be in state " + DownloadStatus.WAITING + " or " + DownloadStatus.ERROR);
        }

        try {
            Files.deleteIfExists(tempFile);
        } catch (IOException ex) {
            LOG.log(Level.WARNING, "Error removing temporary download file.", ex);
        }
    }

    /**
     * Returns the estimated time in seconds for this download.
     *
     * @return
     */
    public long getEta() {
        double bytesPerSecond = getBytesPerSecond();
        Long fileSize = getUrlStatus().getFileSize();
        long currentSize = getCurrentSize();

        if (bytesPerSecond > 0 && fileSize != null) {
            return (long) ((fileSize - currentSize) / bytesPerSecond);
        }
        return 0;
    }

    /**
     * Retrieves the current speed of the downloads.
     *
     * @return
     */
    public double getBytesPerSecond() {
        if (throttledInputStream != null) {
            return throttledInputStream.getCurrentBytesPerSecond();
        }
        return 0;
    }

    /**
     * Returns the current {@linkplain DownloadStatus} of the download.
     *
     * @return {@linkplain DownloadStatus} instance
     */
    public DownloadStatus getDownloadStatus() {
        if (done) {
            return DownloadStatus.FINISHED_SUCCESSFUL;
        }
        return statusHandler.getCurrentStatus();
    }

    /**
     * Returns the last exception which occured for this {@linkplain Download}.
     * This might (or should) be null.
     *
     * @return last exception or null
     */
    public Exception getError() {
        return statusHandler.getErrorException();
    }

    /**
     * Returns the target {@linkplain Path} where the file will be stored on the
     * local machine.
     *
     * @return path object
     */
    public Path getFile() {
        return targetFile;
    }

    /**
     * Returns the current progress of the download. This is a percentage number
     * between 0 and 1.
     *
     * @return
     */
    public Double getProgress() {
        if (done) {
            return 1D;
        }

        Long totalFilesize = getUrlStatus().getFileSize();
        if (totalFilesize == null || totalFilesize == 0D) {
            return 0D;
        }
        long currentSize = getCurrentSize();
        return ((double) currentSize / (double) totalFilesize);
    }

    /**
     * Throttles the download to the given maximum bytes per second.
     *
     * @param maxBytesPerSecond
     */
    public void setThrottle(long maxBytesPerSecond) {
        this.throttleMaxBytesPerSecond = maxBytesPerSecond;
        if (throttledInputStream != null) {
            throttledInputStream.setThrottle(maxBytesPerSecond);
        }
    }

    /**
     * Returns the currently downloaded size of the file in bytes.
     *
     * @return
     */
    public long getCurrentSize() {
        try {
            if (!Files.exists(tempFile)) {
                if (Files.exists(targetFile)) {
                    return Files.size(targetFile);
                }
                return 0;
            }
            return Files.size(tempFile);
        } catch (IOException ex) {
            throw new IllegalStateException("Cannot retrieve size of temporary file:" + tempFile, ex);
        }
    }

    /**
     * Returns the filename of the file to download. This will not include any
     * paths.
     *
     * @return
     */
    public String getFilename() {
        return targetFile.getFileName().toString();
    }

    /**
     * Returns the provider which is used to download the file.
     *
     * @return
     */
    public String getProviderName() {
        return storageProvider.getName();
    }

    /**
     * Returns the {@linkplain URL} where the file is downloaded from.
     *
     * @return
     */
    public URL getUrl() {
        return url;
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 23 * hash + (this.identifier != null ? this.identifier.hashCode() : 0);
        return hash;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final Download other = (Download) obj;
        if ((this.identifier == null) ? (other.identifier != null) : !this.identifier.equals(other.identifier)) {
            return false;
        }
        return true;
    }

    @Override
    public String toString() {
        return "Download{identifier=" + identifier + ", url=" + url + ", state=" + getDownloadStatus() + ", created=" + created + '}';
    }
}
